Skip to content

fix: UriTemplate.match() handles optional, out-of-order, and encoded query parameters#1785

Open
felixweinberger wants to merge 10 commits intomainfrom
fweinberger/uri-template-query-params
Open

fix: UriTemplate.match() handles optional, out-of-order, and encoded query parameters#1785
felixweinberger wants to merge 10 commits intomainfrom
fweinberger/uri-template-query-params

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

Supersedes #1396. Implementation by @mgyarmathy, with a refinement to how absent query parameters are represented.

Fixes UriTemplate.match() to correctly handle query parameter templates per RFC 6570 semantics:

  • Query parameters are now optional (previously required)
  • Query parameters can appear in any order (previously had to match template order exactly)
  • URL-encoded values are decoded
  • Extra query parameters in the URI are ignored

Motivation and Context

Fixes #149
Fixes #1079

Previously, a resource template like products{?page,limit} would fail to match products or products?limit=10&page=1 (wrong order), returning null and causing a "Resource not found" error. This made query parameter templates effectively unusable.

Design: absent parameters are omitted, not empty string

Absent query parameters are omitted from the result rather than set to ''. This differs from #1396, which returned empty strings. Rationale:

Round-trip fidelity. The SDK's expand() already distinguishes these cases:

expand({})          -> /items
expand({page: ''})  -> /items?page=

Omitting keys preserves expand(match(uri)) === uri. Returning '' would turn /items into /items?page=&limit= on round-trip.

RFC 6570 §2.3 explicitly distinguishes undefined from empty string ("A variable value that is a string of length zero is not considered undefined"). The RFC only specifies expansion, not matching, but this semantic distinction should be preserved in both directions.

Ecosystem convention. The reference JS implementation (uri-templates) omits keys for absent params.

Ergonomics. Enables the idiomatic vars.page ?? defaultValue pattern. Empty string would silently break ?? defaulting.

This means callers can distinguish "parameter absent" (vars.page === undefined) from "parameter present but empty" (vars.page === '', e.g. ?page=).

How Has This Been Tested?

New test cases cover: partial matches, out-of-order params, omitted params, extra params, encoded values, and the absent-vs-empty distinction. All 461 core tests pass.

Breaking Changes

Resource templates with query parameters now match more liberally. If you relied on strict all-params-required matching to reject requests, you'll need to validate explicitly in your callback. See docs/migration.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Core implementation (path/query splitting, Map-based query parsing, URL decoding) by @mgyarmathy in #1396. This PR adds the absent-param-omission refinement, changeset, and migration note.

mgyarmathy and others added 4 commits March 26, 2026 18:11
Absent query parameters are now omitted from the result object rather
than set to empty string, so callers can use `vars.param ?? default`.
This also distinguishes 'param absent' from 'param present but empty'
(e.g. ?q= returns {q: ''}).

Also removes dead code: the isQuery field was always false since query
parts go to a separate array, and the (?:\\?.*)?$ regex suffix was
unreachable since pathPart already excludes the query string.
@felixweinberger felixweinberger requested a review from a team as a code owner March 27, 2026 12:03
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

🦋 Changeset detected

Latest commit: fb65293

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1785

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1785

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1785

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1785

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1785

commit: fb65293

@felixweinberger felixweinberger marked this pull request as draft March 27, 2026 14:56
felixweinberger and others added 3 commits March 27, 2026 16:33
@felixweinberger felixweinberger marked this pull request as ready for review March 27, 2026 17:59
Comment on lines +296 to +304
if (hasLiteralQuery) {
// Match the path regex against the full URI without a trailing
// anchor, then treat everything after the match as the remaining
// query string to parse for {?...}/{&...} expressions.
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
match = uri.match(regex);
if (!match) return null;
queryPart = uri.slice(match[0].length).replace(/^&/, '');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In the hasLiteralQuery branch (line 296-304), the regex has no trailing anchor, so it prefix-matches literal query values. For example, template /path?id=1{&extra} matched against /path?id=100 returns {} instead of null, because ^/path\?id=1 matches the prefix /path?id=1 within /path?id=100. Adding a boundary assertion like (?=[&#]|$) after the pattern would fix this.

Extended reasoning...

What the bug is

In the hasLiteralQuery branch of match() (lines 296-304), the path regex is matched against the full URI without a trailing anchor ($) or any boundary assertion. The regex pattern starts with ^ but nothing constrains where it must end. This means any literal query value in the template can prefix-match a longer value in the URI, producing false positive matches.

Concrete example: value prefix collision

Template: /path?id=1{&extra}
URI: /path?id=100

  1. The template has a literal ? in the string segment "/path?id=1", so hasLiteralQuery is true.
  2. The path regex is built as ^/path\?id=1 (no trailing anchor).
  3. Matching against /path?id=100: the regex ^/path\?id=1 matches the prefix /path?id=1 within the URI.
  4. The remainder is uri.slice(match[0].length) = "00".
  5. "00" is split by &, yielding ["00"]. The pair "00" has no = sign (equalIndex === -1), so it is skipped.
  6. No query parameters are found for extra, so the result is {}.
  7. Expected: null — the URI /path?id=100 should not match because id=100id=1.

Second example: malformed URI accepted

Template: /path?a=1{&b}
URI: /path?a=1b=2 (no & separator — malformed)

  1. Regex ^/path\?a=1 prefix-matches /path?a=1 within /path?a=1b=2.
  2. Remainder is "b=2", which is split by & yielding ["b=2"].
  3. The pair b=2 has equalIndex=1, so key="b", value="2".
  4. b matches the template query part, so result is {b: "2"}.
  5. Expected: null — the URI is malformed (no & before b) and the actual value of a is 1b=2, not 1.

Why existing code does not prevent it

The non-hasLiteralQuery branch correctly anchors the path regex with $ and splits the URI at ?, so it does not have this problem. But the hasLiteralQuery branch intentionally omits the anchor to allow the regex to match the path+literal-query portion and then treat the rest as dynamic query parameters. The issue is that without any boundary assertion, the regex can match at any point within a query value, not just at a parameter boundary.

Impact

Any template that uses a literal ? followed by {&...} continuation operators (e.g., /path?static=1{&dynamic}) will incorrectly match URIs where the literal query value is a prefix of the actual value. This can cause incorrect resource routing — a request for /path?id=100 would incorrectly match a template expecting id=1.

How to fix

Add a lookahead assertion after the pattern to ensure the match ends at a query parameter boundary:

if (hasLiteralQuery) {
    pattern += '(?=[&#]|$)';  // ensure match ends at & separator or end of string
    UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
    const regex = new RegExp(pattern);
    // ...
}

This ensures the regex only matches when the literal portion is followed by &, #, or end-of-string — not in the middle of a query parameter value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

# MCP SDK ResourceTemplate URI Validation Issue: RFC 6570 Template Matching Behavior Unordered and partial query parameter matching

2 participants